Skip to content

feat(core-architecture): Maven reactor, common module and ws skeleton - #1

Merged
EdgarEldy merged 30 commits into
developfrom
feature/core-architecture
Sep 19, 2026
Merged

EdgarEldy merged 30 commits into
developfrom
feature/core-architecture

Conversation

@EdgarEldy

Copy link
Copy Markdown
Owner

Branch

feature/core-architecture

Task checklist

  • Parent pom.xml (packaging pom, modules core/ws, dependencyManagement for Spring Framework 6.2.x, Testcontainers, etc.)
  • core/pom.xml (packaging pom, modules common/auth/catalog/customer/order)
  • core/common: ResourceNotFoundException, BusinessRuleException, AbstractDao, DataSourceConfig
  • Flyway script V1__init_schema.sql
  • ws skeleton: WebAppInitializer, WebMvcConfig, ApiResponse<T>, PageResponse<T>, GlobalExceptionHandler
  • docker-compose.yml, Dockerfile (multi-stage: Maven build, Tomcat 10.1 runtime)
  • .github/workflows/ci.yml: mvn verify across the whole reactor

Commit summary

  • Maven reactor: parent dependency management (Spring 6.2.19, Security 6.5.11, Testcontainers...), core aggregator with the five modules, common and ws poms
  • common: exceptions, V1 schema, AbstractDao, DataSourceConfig (Hikari, Flyway before DAOs, transactions), health slice
  • ws: ApiResponse/PageResponse, GlobalExceptionHandler, health route, WebMvcConfig, WebAppInitializer
  • tests: Testcontainers PostgreSQL support, Flyway, DataSourceConfig, AbstractDao, health, MockMvc error mapping, full context boot
  • Docker, docker-compose, CI, commit lint, PR template
  • fixes from review: no ErrorResponse detail leak, validator bean, default converters kept, health failure logged

Deviations: OpenApiConfig not delivered (springdoc needs Spring Boot); health route added; explicit PostgreSQL driver class (found by testing the packaged WAR).

Test plan

  • Tests exist at every layer the branch's checklist calls for (43 tests: common 14, ws 29)
  • Each DAO test class loads its own dedicated SQL fixture file
  • mvn clean verify is green on the whole reactor
  • docker compose up --build: /spring-jdbc-tutorial/api/v1/health answers UP, 404 and 405 in the ApiResponse format

Every module now inherits JUnit 5, Mockito, AssertJ and Spring Test with
test scope from the parent, so a module pom only lists what is specific to
it. The parent also manages the common test-jar, letting the DAO tests of
each business module reuse the shared PostgreSQL Testcontainers helper
instead of copying it.

Testcontainers and the PostgreSQL driver stay out of the shared list on
purpose: only the modules that really run DAO tests declare them.
The core aggregator now lists every business module of the README. Each one
starts as an empty jar that depends only on common; order additionally
depends on catalog and customer, which is the single cross-module edge of
the design (through their service interfaces once they exist).

Declaring them now lets ws depend on the whole core from the start and keeps
each later branch limited to adding classes, not restructuring the reactor.
common carries what every business module needs: spring-jdbc for
JdbcTemplate and transactions, spring-context for @configuration, HikariCP
for pooling, the PostgreSQL driver, Flyway with its PostgreSQL module, and
the jakarta.persistence annotations used only as documentation on entities.

It also publishes its test classes as a test-jar (maven-jar-plugin test-jar
goal), so the Testcontainers PostgreSQL holder written once here can be
imported by the DAO tests of the other modules.
ws is the web layer: it depends on every core module, on spring-webmvc and
on Jackson with the JSR-310 module for Instant. The Servlet API is provided
scope because Tomcat 10.1 supplies Servlet 6.0 at runtime, and Bean
Validation runs standalone on Hibernate Validator with its Expressly EL.

The WAR plugin is told there is no web.xml, since a
WebApplicationInitializer replaces it, and the artifact is named ws so the
package is ws/target/ws.war as the README states.
These two unchecked exceptions are the shared vocabulary between services
and the web layer. A service throws ResourceNotFoundException when an id
matches nothing and BusinessRuleException when a domain rule refuses an
operation; neither knows anything about HTTP.

Keeping them in common lets every core module raise them without depending
on ws, while GlobalExceptionHandler translates them to 404 and 422 later.
The migration creates every table of the README data model: the identity and
RBAC domain (users, roles, permissions, join tables, activation, blacklist
and reset tokens, audit logs) and the e-commerce domain (categories,
products, customers, orders). Flyway is the only thing that creates or
alters tables, no framework generates the schema.

Constraints carry the invariants the database can enforce: a unique index on
the lower-cased email, tokens stored as 64 character digests, restrictive
foreign keys where a service must refuse the delete first, and no foreign
key between the two domains.
JdbcTemplate is Spring JDBC's core class: it borrows a connection, runs the
SQL, binds the parameters, and translates SQLException into Spring's
unchecked DataAccessException hierarchy. A RowMapper turns one ResultSet row
into one object, which together replace what an ORM would hide.

AbstractDao holds the shared template and three helpers every DAO reuses:
findOne (first row or empty, never EmptyResultDataAccessException),
insertReturningId for PostgreSQL's INSERT ... RETURNING id, and count.
SQL itself stays in the concrete DAO classes.
DataSourceConfig is the only place these infrastructure beans exist. A
@configuration class declares beans explicitly with @bean instead of relying
on scanning or auto-configuration. The HikariCP pool reads its settings with
getRequiredProperty, so a missing url, user or password fails the startup
with a clear message and no default secret exists.

Flyway migrates as the bean is created and JdbcTemplate and the
DataSourceTransactionManager depend on it, so migrations always run before
any DAO. @EnableTransactionManagement makes @transactional service beans
proxied. The PostgreSQL driver class is named explicitly because inside a
WAR DriverManager cannot see a driver held in WEB-INF/lib.
A tiny vertical slice gives the packaged WAR a public route to probe: a DAO
runs SELECT 1, a service turns the outcome into a HealthDto (DOWN when a
DataAccessException is raised) and CommonConfig registers both as explicit
@bean methods typed by their interfaces, importing DataSourceConfig.

It shows the layering without an entity: the DAO owns SQL, the service owns
the decision, and only a dto leaves the module. No stereotype annotation is
used on either implementation.
ApiResponse<T> wraps every answer, success or error, in the same envelope
(success, message, data, timestamp) built through two static factories.
PageResponse<T> carries a page of results with its totals and refuses a size
below 1, so a bad size can never produce a fake total.

They are named payload, not dto, on purpose: core dto classes are the
business shape, payload classes are the HTTP shape. They are plain classes
with explicit accessors, no Lombok and no record.
…dler

@RestControllerAdvice makes these @ExceptionHandler methods apply to every
controller, so exceptions become HTTP in exactly one place. Domain
exceptions give 404 and 422, Bean Validation failures give 400 with one
"field: message" entry per error, malformed JSON, bad parameters, unknown
URLs, wrong methods and media types get their own statuses.

The catch-all returns a generic 500 and only logs the real error, so no
internal detail leaks. A marked spot is left for the 401 and 403 handlers
that the auth branch adds with Spring Security.
HealthController exposes GET /api/v1/health. It sees only the HealthService
interface and a payload type: a converter maps the core HealthDto to the
HealthResponse, keeping core dto and web payload distinct.

The route lets a packaged WAR prove that it starts, reaches PostgreSQL and
answers in the ApiResponse envelope.
@EnableWebMvc turns on Spring MVC in a plain Spring application, without
Boot, and WebMvcConfigurer customises it: one Jackson converter that
writes Instants as ISO-8601 text and a Hibernate Validator backed
validator. The class imports the core configuration, and every later branch
adds its module config to the same import.

The component scan is limited to the controller and exception packages:
those are the only stereotypes of the project, DAOs and services are never
scanned.
Tomcat discovers WebApplicationInitializer implementations through Spring's
servlet container initializer, so servlets and filters are registered in
Java. The initializer builds one annotation-based context, exposes it as the
root context through ContextLoaderListener and hands the same context to the
DispatcherServlet mapped on "/".

Sharing one context keeps every bean defined once and lets the security
filter added later, which lives outside the DispatcherServlet, find it in
the root context. A UTF-8 character encoding filter is registered as well.
A singleton Testcontainers PostgreSQL is started once per JVM and its
connection settings are injected into the Spring test context with
@DynamicPropertySource. The holder is published in the common test-jar so
other modules reuse it.

FlywayMigrationTest checks that all thirteen tables exist after startup,
that the unique email index is case-insensitive and that the restrictive
foreign key blocks deleting a referenced category.
The context is built from an empty MockEnvironment, with no database, and
must fail with a clear message when the url or the password is missing.
The test also asserts that no default password leaks into the message.

It documents the rule that a wrong deployment fails at startup instead of
silently using a hidden default.
Each DAO test class loads its own dedicated SQL fixture through @SQL before
every test: abstract-dao-dataset.sql for the AbstractDao helpers (findOne
present and empty, increasing generated ids, count) and
health-dao-dataset.sql for the health DAO. No dataset is shared between
classes.

The fixture of the AbstractDao test restarts the sequence explicitly,
because rows inserted with explicit ids do not advance it.
Mockito replaces the DAO, so no database is involved: the service must
report UP when the DAO answers true, and DOWN both when it answers false and
when it throws a DataAccessException.

This is the service layer of the strategy: business decisions verified in
isolation, with assertions on the dto that leaves the module.
The tests check the success and error factories, the ceiling computation of
totalPages, the refusal of a size of zero or below, and that a page beyond
the last one keeps an empty content list.

They are plain unit tests without Spring: payload classes hold no logic
beyond construction.
…ckMvc

MockMvc drives a WebApplicationContext built from a small test config, a
test-only controller that throws each case and the real
GlobalExceptionHandler. Every error is asserted in the ApiResponse shape:
404, 422, 400 with per-field messages, malformed JSON, constrained request
parameters, unknown URL, 405 with its Allow header, 415 and a 500 that does
not leak the real message.

The health controller is tested with the service mocked. These classes live
in ws.mvc because the component scan of WebMvcConfig would otherwise pick
up test controllers.
The integration test loads the real WebMvcConfig, hence CommonConfig,
DataSourceConfig and Flyway, on a Testcontainers database and checks that
the health route answers 200 with status UP and that an unknown URL gives a
404 in the ApiResponse envelope.

WebAppInitializerTest uses a mocked ServletContext to verify the
registrations: the dispatcher servlet on "/", the ContextLoaderListener and
the UTF-8 filter.
The first stage builds the reactor with Maven, copying the poms first so
the dependency download is cached until a pom changes. The second stage is
a Tomcat 10.1 JRE 17 image that keeps only the WAR, deployed as
spring-jdbc-tutorial.war so the context path matches the URLs of the README.

The image runs as an unprivileged user and the build context excludes
everything but the sources. Tests are skipped in the image build: the DAO
tests need a Docker daemon and run in CI instead.
The compose file runs the packaged application beside a PostgreSQL 16
container with a health check. The database password is mandatory and has no
default, the published port is configurable through APP_PORT because 8080
is often taken, and the datasource is passed as environment variables that
Spring maps to app.datasource.* properties.

It is meant for the packaged WAR only: the tests start their own database
through Testcontainers. .env.example lists the variables to copy into .env.
The workflow checks out the code, sets up Temurin Java 17 with the Maven
cache and runs ./mvnw -B verify on pushes and pull requests to master and
develop.

There is no services block for PostgreSQL on purpose: the DAO tests start
their own container through Testcontainers, using the Docker daemon already
present on ubuntu-latest, exactly as they do locally.
On every pull request the workflow reads the commits of the range between
the base and the head and rejects any subject that does not match
type(scope): summary. The scope is mandatory and the summary is limited to
100 characters.

Merge commits are skipped, so the merge of a feature branch does not need
to follow the convention.
The template asks for the branch name, the ticked task checklist copied from
the README, a commit summary and a test plan covering the five test layers
and the dedicated fixture files.

The review checklist encodes the rules of this project: no business logic in
controllers, layer boundaries between entity and dto, ApiResponse everywhere,
declarative permissions, no stereotype on DAOs and services, no Lombok, no
record, no ORM, audit logging and Flyway as the only schema source.
All seven core-architecture tasks are checked. The project structure now
states two facts learned while implementing them: OpenApiConfig is not
delivered because springdoc-openapi needs Spring Boot classes, and
WebMvcConfig scans only the controller and exception packages, the sole
stereotype-annotated classes of the project.

The README stays the specification, so corrections found in practice are
written back into it.
Spring MVC exceptions that implement ErrorResponse and have no dedicated
handler used to reach the catch-all, which copied their detail text into
the message, so framework wording could reach the client. The handler now
keeps only the status they carry and answers with its standard reason
phrase, or a generic message plus an error log for a 5xx.

A non-regression test throws an ErrorResponseException carrying a secret
detail and checks that only the reason phrase is returned.
The LocalValidatorFactoryBean is now a @bean, so Spring manages its
lifecycle and injects the context, and getValidator() returns that same
instance instead of building a new unmanaged one on every call.

The Jackson customisation moved from configureMessageConverters to
extendMessageConverters: the default converters (String, byte[], Resource)
are kept and only the default Jackson converter is replaced by the one that
writes Instants as ISO-8601 text.
HealthServiceImpl swallowed the DataAccessException and reported a bare
DOWN, leaving an operator blind to the reason (bad credentials, network,
exhausted pool). It now logs a warning with the exception before answering
DOWN.

The logger is Spring's own commons-logging facade (spring-jcl), so core
still needs no logging dependency: the ws module picks the backend.
@EdgarEldy
EdgarEldy merged commit 4ecd869 into develop Sep 19, 2026
2 checks passed
@EdgarEldy EdgarEldy mentioned this pull request Sep 19, 2026
8 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant